/*
 * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
 */
package at.bitfire.davdroid.servicedetection

import android.app.ActivityManager
import android.content.Context
import androidx.core.content.getSystemService
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.Response
import at.bitfire.dav4jvm.okhttp.UrlUtils
import at.bitfire.dav4jvm.okhttp.exception.DavException
import at.bitfire.dav4jvm.okhttp.exception.HttpException
import at.bitfire.dav4jvm.okhttp.exception.UnauthorizedException
import at.bitfire.dav4jvm.property.caldav.CalDAV
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
import at.bitfire.dav4jvm.property.caldav.CalendarUserAddressSet
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
import at.bitfire.dav4jvm.property.carddav.CardDAV
import at.bitfire.dav4jvm.property.common.HrefListProperty
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrincipal
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.dav4jvm.property.webdav.WebDAV
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.log.StringHandler
import at.bitfire.davdroid.network.DnsRecordResolver
import at.bitfire.davdroid.network.HttpClientBuilder
import at.bitfire.davdroid.settings.Credentials
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.xbill.DNS.Type
import java.io.InterruptedIOException
import java.net.SocketTimeoutException
import java.net.URI
import java.net.URISyntaxException
import java.util.LinkedList
import java.util.logging.Level
import java.util.logging.Logger

/**
 * Does initial resource detection when an account is added. It uses the (user given) base URL to find
 *
 * - services (CalDAV and/or CardDAV),
 * - principal,
 * - homeset/collections (multistatus responses are handled through dav4jvm).
 *
 * @param context        to build the HTTP client
 * @param baseURI        user-given base URI (either mailto: URI or http(s):// URL)
 * @param credentials    optional login credentials (username/password, client certificate, OAuth state)
 */
class DavResourceFinder @AssistedInject constructor(
    @Assisted private val baseURI: URI,
    @Assisted private val credentials: Credentials? = null,
    @ApplicationContext val context: Context,
    private val dnsRecordResolver: DnsRecordResolver,
    httpClientBuilder: HttpClientBuilder
) {

    @AssistedFactory
    interface Factory {
        fun create(baseURI: URI, credentials: Credentials?): DavResourceFinder
    }

    enum class Service(val wellKnownName: String) {
        CALDAV("caldav"),
        CARDDAV("carddav");

        override fun toString() = wellKnownName
    }

    val log: Logger = Logger.getLogger(javaClass.name)
    private val logBuffer: StringHandler = initLogging()

    private var encountered401 = false

    private val httpClient = httpClientBuilder
        .setLogger(log)
        .apply {
            if (credentials != null)
                authenticate(
                    domain = null,
                    getCredentials = { credentials }
                )
            }
        .build()

    private fun initLogging(): StringHandler {
        // don't use more than 1/4 of the available memory for a log string
        val activityManager = context.getSystemService<ActivityManager>()!!
        val maxLogSize = activityManager.memoryClass * (1024 * 1024 / 8)
        val handler = StringHandler(maxLogSize)

        // add StringHandler to logger
        log.level = Level.ALL
        log.addHandler(handler)

        return handler
    }


    /**
     * Finds the initial configuration (= runs the service detection process).
     *
     * In case of an error, it returns an empty [Configuration] with error logs
     * instead of throwing an [Exception].
     *
     * @return service information – if there's neither a CalDAV service nor a CardDAV service,
     * service detection was not successful
     */
    fun findInitialConfiguration(): Configuration {
        var cardDavConfig: Configuration.ServiceInfo? = null
        var calDavConfig: Configuration.ServiceInfo? = null

        try {
            try {
                cardDavConfig = findInitialConfiguration(Service.CARDDAV)
            } catch (e: Exception) {
                log.log(Level.INFO, "CardDAV service detection failed", e)
                processException(e)
            }

            try {
                calDavConfig = findInitialConfiguration(Service.CALDAV)
            } catch (e: Exception) {
                log.log(Level.INFO, "CalDAV service detection failed", e)
                processException(e)
            }
        } catch(_: Exception) {
            // we have been interrupted; reset results so that an error message will be shown
            cardDavConfig = null
            calDavConfig = null
        }

        return Configuration(
            cardDAV = cardDavConfig,
            calDAV = calDavConfig,
            encountered401 = encountered401,
            logs = logBuffer.toString()
        )
    }

    private fun findInitialConfiguration(service: Service): Configuration.ServiceInfo? {
        // domain for service discovery
        var discoveryFQDN: String? = null

        // discovered information goes into this config
        val config = Configuration.ServiceInfo()

        // Start discovering
        log.info("Finding initial ${service.wellKnownName} service configuration")
        when (baseURI.scheme.lowercase()) {
            "http", "https" ->
                baseURI.toHttpUrlOrNull()?.let { baseURL ->
                    // remember domain for service discovery
                    if (baseURL.scheme.equals("https", true))
                        // service discovery will only be tried for https URLs, because only secure service discovery is implemented
                        discoveryFQDN = baseURL.host

                    // Actual discovery process
                    checkBaseURL(baseURL, service, config)

                    // If principal was not found already, try well known URI
                    if (config.principal == null)
                        try {
                            config.principal = getCurrentUserPrincipal(baseURL.resolve("/.well-known/" + service.wellKnownName)!!, service)
                        } catch(e: Exception) {
                            log.log(Level.FINE, "Well-known URL detection failed", e)
                            processException(e)
                        }
                }
            "mailto" -> {
                val mailbox = baseURI.schemeSpecificPart
                val posAt = mailbox.lastIndexOf("@")
                if (posAt != -1)
                    discoveryFQDN = mailbox.substring(posAt + 1)
            }
        }

        // Second try: If user-given URL didn't reveal a principal, search for it (SERVICE DISCOVERY)
        if (config.principal == null)
            discoveryFQDN?.let { fqdn ->
                log.info("No principal found at user-given URL, trying to discover for domain $fqdn")
                try {
                    config.principal = discoverPrincipalUrl(fqdn, service)
                } catch(e: Exception) {
                    log.log(Level.FINE, "$service service discovery failed", e)
                    processException(e)
                }
            }

        // detect email address
        if (service == Service.CALDAV)
            config.principal?.let { principal ->
                config.emails.addAll(queryEmailAddress(principal))
            }

        // return config or null if config doesn't contain useful information
        val serviceAvailable = config.principal != null || config.homeSets.isNotEmpty() || config.collections.isNotEmpty()
        return if (serviceAvailable)
            config
        else
            null
    }

    /**
     * Entry point of the actual discovery process.
     *
     * Queries the user-given URL (= base URL) to detect whether it contains a current-user-principal
     * or whether it is a homeset or collection.
     *
     * @param baseURL   base URL provided by the user
     * @param service   service to detect configuration for
     * @param config    found configuration will be written to this object
     */
    private fun checkBaseURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) {
        log.info("Checking user-given URL: $baseURL")

        val davBaseURL = DavResource(httpClient, baseURL, log)
        try {
            when (service) {
                Service.CARDDAV -> {
                    davBaseURL.propfind(
                        0,
                        WebDAV.ResourceType, WebDAV.DisplayName,
                        WebDAV.CurrentUserPrincipal,
                        CardDAV.AddressbookHomeSet,
                        CardDAV.AddressbookDescription
                    ) { response, _ ->
                        scanResponse(CardDAV.Addressbook, response, config)
                    }
                }
                Service.CALDAV -> {
                    davBaseURL.propfind(
                        0,
                        WebDAV.ResourceType, WebDAV.DisplayName,
                        WebDAV.CurrentUserPrincipal, WebDAV.CurrentUserPrivilegeSet,
                        CalDAV.CalendarHomeSet,
                        CalDAV.SupportedCalendarComponentSet, CalDAV.CalendarColor, CalDAV.CalendarDescription, CalDAV.CalendarTimezone
                    ) { response, _ ->
                        scanResponse(CalDAV.Calendar, response, config)
                    }
                }
            }
        } catch(e: Exception) {
            log.log(Level.FINE, "PROPFIND/OPTIONS on user-given URL failed", e)
            processException(e)
        }
    }

    /**
     * Queries a user's email address using CalDAV scheduling: calendar-user-address-set.
     * @param principal principal URL of the user
     * @return list of found email addresses (empty if none)
     */
    fun queryEmailAddress(principal: HttpUrl): List<String> {
        val mailboxes = LinkedList<String>()
        try {
            DavResource(httpClient, principal, log).propfind(0, CalDAV.CalendarUserAddressSet) { response, _ ->
                response[CalendarUserAddressSet::class.java]?.let { addressSet ->
                    for (href in addressSet.hrefs)
                        try {
                            val uri = URI(href)
                            if (uri.scheme.equals("mailto", true))
                                mailboxes.add(uri.schemeSpecificPart)
                        } catch(e: URISyntaxException) {
                            log.log(Level.WARNING, "Couldn't parse user address", e)
                        }
                }
            }
        } catch(e: Exception) {
            log.log(Level.WARNING, "Couldn't query user email address", e)
            processException(e)
        }
        return mailboxes
    }

    /**
     * Depending on [resourceType] (CalDAV or CardDAV), this method checks whether [davResponse] references
     * - an address book or calendar (actual resource), and/or
     * - an "address book home set" or a "calendar home set", and/or
     * - whether it's a principal.
     *
     * Respectively, this method will add the response to [config.collections], [config.homesets] and/or [config.principal].
     * Collection URLs will be stored with trailing "/".
     *
     * @param resourceType  type of service to search for in the response
     * @param davResponse   response whose properties are evaluated
     * @param config        structure storing the references
     */
    fun scanResponse(resourceType: Property.Name, davResponse: Response, config: Configuration.ServiceInfo) {
        var principal: HttpUrl? = null

        // Type mapping
        val homeSetClass: Class<out HrefListProperty>
        val serviceType: Service
        when (resourceType) {
            CardDAV.Addressbook -> {
                homeSetClass = AddressbookHomeSet::class.java
                serviceType = Service.CARDDAV
            }
            CalDAV.Calendar -> {
                homeSetClass = CalendarHomeSet::class.java
                serviceType = Service.CALDAV
            }
            else -> throw IllegalArgumentException()
        }

        // check for current-user-principal
        davResponse[CurrentUserPrincipal::class.java]?.href?.let { currentUserPrincipal ->
            principal = davResponse.requestedUrl.resolve(currentUserPrincipal)
        }

        davResponse[ResourceType::class.java]?.let {
            // Is it a calendar or an address book, ...
            if (it.types.contains(resourceType))
                Collection.fromDavResponse(davResponse)?.let { info ->
                    log.info("Found resource of type $resourceType at ${info.url}")
                    config.collections[info.url] = info
                }

            // ... and/or a principal?
            if (it.types.contains(WebDAV.Principal))
                principal = davResponse.href
        }

        // Is it an addressbook-home-set or calendar-home-set?
        davResponse[homeSetClass]?.let { homeSet ->
            for (href in homeSet.hrefs) {
                davResponse.requestedUrl.resolve(href)?.let {
                    val location = UrlUtils.withTrailingSlash(it)
                    log.info("Found home-set of type $resourceType at $location")
                    config.homeSets += location
                }
            }
        }

        // Is there a principal too?
        principal?.let {
            if (providesService(it, serviceType))
                config.principal = principal
            else
                log.warning("Principal $principal doesn't provide $serviceType service")
        }
    }

    /**
     * Sends an OPTIONS request to determine whether a URL provides a given service.
     *
     * @param url      URL to check; often a principal URL
     * @param service  service to check for
     *
     * @return whether the URL provides the given service
     */
    fun providesService(url: HttpUrl, service: Service): Boolean {
        var provided = false
        try {
            DavResource(httpClient, url, log).options { capabilities, _ ->
                if ((service == Service.CARDDAV && capabilities.contains("addressbook")) ||
                    (service == Service.CALDAV && capabilities.contains("calendar-access")))
                    provided = true
            }
        } catch(e: Exception) {
            log.log(Level.SEVERE, "Couldn't detect services on $url", e)
            if (e !is HttpException && e !is DavException)
                throw e
        }
        return provided
    }


    /**
     * Try to find the principal URL by performing service discovery on a given domain name.
     * Only secure services (caldavs, carddavs) will be discovered!
     *
     * @param domain         domain name, e.g. "icloud.com"
     * @param service        service to discover (CALDAV or CARDDAV)
     * @return principal URL, or null if none found
     */
    fun discoverPrincipalUrl(domain: String, service: Service): HttpUrl? {
        val scheme: String
        val fqdn: String
        var port = 443
        val paths = LinkedList<String>()     // there may be multiple paths to try

        val query = "_${service.wellKnownName}s._tcp.$domain"
        log.fine("Looking up SRV records for $query")

        val srvRecords = dnsRecordResolver.resolve(query, Type.SRV)
        val srv = dnsRecordResolver.bestSRVRecord(srvRecords)

        if (srv != null) {
            // choose SRV record to use (query may return multiple SRV records)
            scheme = "https"
            fqdn = srv.target.toString(true)
            port = srv.port
            log.info("Found $service service at https://$fqdn:$port")
        } else {
            // no SRV records, try domain name as FQDN
            log.info("Didn't find $service service, trying at https://$domain:$port")

            scheme = "https"
            fqdn = domain
        }

        // look for TXT record too (for initial context path)
        val txtRecords = dnsRecordResolver.resolve(query, Type.TXT)
        paths.addAll(dnsRecordResolver.pathsFromTXTRecords(txtRecords))

        // in case there's a TXT record, but it's wrong, try well-known
        paths.add("/.well-known/" + service.wellKnownName)
        // if this fails too, try "/"
        paths.add("/")

        for (path in paths)
            try {
                val initialContextPath = HttpUrl.Builder()
                        .scheme(scheme)
                        .host(fqdn).port(port)
                        .encodedPath(path)
                        .build()

                log.info("Trying to determine principal from initial context path=$initialContextPath")
                val principal = getCurrentUserPrincipal(initialContextPath, service)

                principal?.let { return it }
            } catch(e: Exception) {
                log.log(Level.WARNING, "No resource found", e)
                processException(e)
            }
        return null
    }

    /**
     * Queries a given URL for current-user-principal
     *
     * @param url       URL to query with PROPFIND (Depth: 0)
     * @param service   required service (may be null, in which case no service check is done)
     * @return          current-user-principal URL that provides required service, or null if none
     */
    fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? {
        var principal: HttpUrl? = null
        DavResource(httpClient, url, log).propfind(0, WebDAV.CurrentUserPrincipal) { response, _ ->
            response[CurrentUserPrincipal::class.java]?.href?.let { href ->
                response.requestedUrl.resolve(href)?.let {
                    log.info("Found current-user-principal: $it")

                    // service check
                    if (service != null && !providesService(it, service))
                        log.warning("Principal $it doesn't provide $service service")
                    else
                        principal = it
                }
            }
        }
        return principal
    }

    /**
     * Processes a thrown exception like this:
     *
     *   - If the Exception is an [UnauthorizedException] (HTTP 401), [encountered401] is set to *true*.
     *   - Re-throws the exception if it signals that the current thread was interrupted to stop the current operation.
     */
    private fun processException(e: Exception) {
        if (e is UnauthorizedException)
            encountered401 = true
        else if ((e is InterruptedIOException && e !is SocketTimeoutException) || e is InterruptedException)
            throw e
    }


    // data classes

    class Configuration(
        val cardDAV: ServiceInfo?,
        val calDAV: ServiceInfo?,

        val encountered401: Boolean,
        val logs: String
    ) {

        data class ServiceInfo(
            var principal: HttpUrl? = null,
            val homeSets: MutableSet<HttpUrl> = HashSet(),
            val collections: MutableMap<HttpUrl, Collection> = HashMap(),

            val emails: MutableList<String> = LinkedList()
        )

        override fun toString() =
            "DavResourceFinder.Configuration(cardDAV=$cardDAV, calDAV=$calDAV, encountered401=$encountered401, logs=(${logs.length} chars))"

    }

}
